【第2133期】如何搭建一套 “无痕埋点” 体系?
前言
今日前端早读课文章由涂鸦智能@聂风投稿分享。
@王立康,花名聂风,目前任职于涂鸦智能大前端-BI大数据组,主要负责数据及可视化研发工作。
正文从这开始~~
本文主要讲解如何搭建一套 无痕埋点 数据采集体系。
需求背景介绍
统计页面的浏览量 pv 和用户量 uv 。
统计在什么时间段用户访问页面流量最大,统计单个用户访问页面的时长。
公司网站在很多其它网站都打了广告,判断从哪个网站或是哪种方式来的用户流量更大,把价值最大化。
商城页面中把商品点击量、浏览量最高的往前放点,还可以针对性对用户进行营销,提升销量。
......
利用数据分析可以做的事情太多了,引用一句话:“数据是工业互联网的基石”。
话不多说,开始吧
为实现埋点数据采集,我们得写一段 js 脚本(sdk),让前端系统接入,将采集到的数据无感知的上报到我们的服务器。
首先,将任务进行分解一下:
写一段 js 脚本,前端通过 script 标签引入这段 js;
通过页面的用户行为,触发相应 js 事件,并拿到数据;
通过请求 nginx 上某一个静态资源的形式,将数据进行拼接发送到 nginx;
nginx 产生一条日志,云端拿到数据进行清洗、分析。
前端接入埋点 js 脚本
我们需要在前端系统中插入一段 script 标签,参考以下代码:
<script>
(function(f, c, d, e, a, b) {
a = c.createElement(d);
b = c.getElementsByTagName(d)[0];
a.async = 1;
a.src = e;
b.parentNode.insertBefore(a, b);
})(
window,
document,
'script',
'https://static1.tuyacn.com/static/ty-lib/tpm3/tpm-x.x.x.min.js'
);
</script>
通过执行这段 script 代码,再以 script 标签的形式,往 dom 中插入真实的 js 代码,里面写一个立即执行函数就行了。那么,我们在这个立即执行函数里写我们要处理的逻辑,就完成了相应的前端 埋点(网页投毒)。那么,我们就正式开始介绍如何实现这段 js。
埋点需要收集哪些数据
首先我们要确认好要收集哪些数据,才可以满足我们的业务需求。以下,我们将数据分为三类:页面信息数据、浏览器数据、用户自定义数据。
1. 页面信息数据
这类数据可以是页面来源、当前站点语言、可视窗口大小、页面加载性能数据等等。
这些数据主要来自浏览器的 window 对象、上一个页面带过来的数据、以及按规则存储在 cookie 中的值。
2. 浏览器数据
这类数据主要有浏览器屏幕尺寸,浏览器类型,浏览器版本等信息。
3. 用户自定义数据
例如,开发者将标志符 "login.click" 表示点击了登录按钮,"login.success" 表示登录成功,"login.fail" 表示登录失败,将这些数据进行上报。这个例子中,有的可以数据可以通过监听 dom 事件的方式拿到,有的数据需要用户手动执行触发,这些都属于用户自定义数据,主要以下面两种方式上报:
第一种:将 "login.click" 埋在 dom 节点上,埋点 js 监听 dom 节点的点击事件,用户点击到该 dom 时,js 解析该 dom 上的数据,获取然后上报。
第二种:需要开发者在获得接口请求结果后将 "login.success" 或 "login.fail" 通过调用埋点 js 暴露出去的 API 触发事件,然后上报。
总之,这类数据是来自 开发者定义 的,也称 代码埋点。除了代码埋点以外,为了让开发者只关心业务逻辑实现,并且更加高效快捷、自动化埋点,还额外增加了 可视化埋点、无痕埋点 两种方式。
实现前端埋点3种技术方案
1. 代码埋点
代码埋点需要开发者在埋点的节点处插入埋点代码,例如点击事件的回调、元素的展示回调方法、页面的生命周期函数等等。
代码埋点的第一种方式,将代码埋点埋在 dom 上
// 代码示例
...
<span data-tpm='vpxRlRxO8f1LAYjWc9jNOcGpIj5Fx6N0' data-tpm-args='{"pid":1, "uid":2}'>登录</span>
...
此时,当有点击事件发生在该 dom 上时,js 对其进行解析:
/**
* js 将代码埋点事件解析上报到 nginx
*/
// 第一步:将全局事件监听挂载在 body 上
document.body.addEventListener('click', function(e) {
...
// 第二步:获取目标元素(这里可以过滤掉点击到 body 的脏数据)
const el = e.target;
// 第三步:获取属性值
const dataTpm = getNodeAttr(el, 'data-tpm'); // vpxRlRxO8f1LAYjWc9jNOcGpIj5Fx6N0
const dataTpmArgs = getNodeAttr(el, 'data-tpm-args'); // {"pid":1, "uid":2}
if (dataTpm || dataTpmArgs) {
// 第四步:将获取到的数据上报到 nginx,产生一条日志
const data = {
type: 'click',
ec: dataTpm,
ea: dataTpmArgs,
...
};
sendToNginx(data);
}
...
}, false);
/**
* 获取 dom 属性值
* @param {HTMLElement} el
* @param {String} attr
*/
function getNodeAttr(el, attr) {
return (el && el.getAttribute && el.getAttribute(attr)) || "";
}
function sendToNginx(info) {
let str = "";
for (let i in info) { // Object.keys(obj) ie 9 以上才兼容, for..in.. ie 6 以上兼容
if (str === "") {
str = i + "=" + info[i];
} else {
str += "&" + i + "=" + info[i];
}
}
const url = 'https://tpm.tuyacn.com/tpm.gif' + '?' + str;
new Image().src = url
}
这样,我们实现了简单的元素 click 事件埋点上报,相应的可以监听的事件还可以有页面的 DOMContentLoaded、 beforeunload、 visibilitychange 事件等。如下图所示,具体可以参考 网页的生命周期
还可以监听元素的 mouseover、mouseout 事件,只是这里不做埋点逻辑处理。
代码埋点的第二种方式,通过 window.track 方法触发埋点上报
当存在以下业务场景时:
统计用户点击 “登录” 成功的次数;
统计商品曝光次数;
其他事件的回调;
上述业务场景不能通过 dom 代码埋点方式获取,js 为此提供了 track 方法,并挂在 window 上,供开发者调用。
// 开发者添加一个 track 事件
window.track('UA', 'vpxRlRxO8f1LAYjWc9jNOcGpIj5Fx6N0', '{"pid":1, "uid":2}');
/**
* js 将 track 事件解析上报到 nginx
*/
function track(type, code, others) {
const data = {
type: 'UA',
ec: code,
ea: others,
...
};
sendToNginx(data);
}
2. 可视化圈选埋点
可视化圈选埋点,通俗的讲就是无需开发者在代码中加入埋点逻辑代码,只需要通过 UI 点点点的方式就能埋好一个点,有效避免了埋点代码污染问题。被点击到的 dom 元素都赋予唯一标识,这里采用 dom 元素唯一的 xpath 当作唯一标识。说白了,可视化圈选埋点就是制定一套规则,云端利用这套规则去海量的数据里清洗出需要的数据,而规则中就包含了 xpath。
这里我们以官网为例介绍一下如何进行可视化圈选埋点:
此时默认官网已经接了埋点 js ,用户在平台(俗称:天眼)的地址栏中,输入官网地址 https://www.tuya.com/cn/ ,点击圈选按钮,就可以开始可视化圈选埋点了。我们提供了以下 3 种业务圈选场景:
圈选当前元素,用户点击在红色框选中的元素会上报
圈选所有子元素,用户点击在红色框内任一元素都会上报
同级元素圈选,帮助用户快速圈选一批元素,当点击在这批元素内任一元素都会上报
使用方式如下图所示:
那么,我们是如何实现的呢?
首先官网页面以 iframe 的形式嵌入在天眼中,然后在官网页面设置响应头 X-Frame-Options: *.tuya-inc.com ,允许被 *.tuya-inc.com 域名下的系统嵌套。再建立父子页面间的通信可以啦,这里通信方式有几种呢?常用的通信方式有 mqtt 通信、iframe 通信、http 请求通信,http 属于单向通信不可以使用。
使用 iframe 通信的优点有:
支持双向通信;
速度快,不经过网络请求,有效避免了网络传输时延;
不需要处理跨域问题;
缺点有:
接了埋点 js 的子页面 ,需要保持监听父页面来的消息;
如果使用 mqtt 通信,那么会是 父页面 <-> mqtt 网关 <-> 子页面页面 这样的链路,
优点:
支持双向通信;
建立连接时使用 http 请求,建立连接之后使用 websocket 通信,消耗网络资源较小;
缺点:
第一次建立连接还是得需要父页面通过 iframe 通信告知子页面需要建立 mqtt 连接;
第一次建立连接需要解决跨域问题;
中间多了一层 mqtt 网关,链路复杂;
综合考虑下来,目前我们采用的是 iframe 通信方式进行可视化埋点,那么我们就开始正式进行数据通信啦 ⚡️
在埋点 js 中添加以下代码:
...
// 子页面接收来自父页面的消息
function tpmReceive() {
window.addEventListener("message", (event) => {
const data = event.data;
}, false);
}
/**
* js 向父页面发送消息
* @param {String} status: "circle" | "browser" // circle 为圈选模式,browser 为浏览模式
*/
function tpmSendToTianyan(data) {
if (window.parent && (status === "circle")) {
window.parent.postMessage(data, "*");
}
}
...
在圈选平台中添加以下代码:
...
// 父页面接收消息
useEffect(() => {
window.addEventListener('message', tianyanReceive, false)
return () => {
window.removeEventListener('message', tianyanReceive, false)
}
}, [url]) // https://www.tuya.com/cn/
// 向子页面 iframe 发送消息
function tianyanSendToTpm(data) {
iframe.current.contentWindow.postMessage(data, '*')
}
...
当子页面在接收到 圈选 的消息时,在 dom 中插入一段 style 标签,添加圈选选中的样式,
// 嵌入 style 标签
function insertStyle() {
if (document.getElementById("tianyan")) {
return;
}
const d = document.createElement("style");
d.setAttribute("type", "text/css");
d.setAttribute("id", "tianyan-circle");
d.innerHTML = `
.tpm-circled-style {
outline: 1px solid red;
outline-offset: -1px;
}
.tpm-circled-style-dashed {
outline: 1px dashed red;
outline-offset: -1px;
}`;
document.getElementsByTagName("head")[0].appendChild(d);
}
// 删除 style 标签
function removeStyle() {
const el = document.getElementById("tianyan-circle");
if (!el) {
return;
}
el.parentNode.removeChild(el);
}
当子页面 dom 在接收到 点击 的消息时,获取当前 dom 的 xpath 发送给父页面。xpath 从控制台也可以拿得到,右键点击元素 -> 复制 -> "xpath",
复制出来就是这样子的一个字符串,
// xpath
"/html/body/div[1]/div/div[2]/div[6]/div[2]/div[3]/div/div/div[1]/div/div/div/div[2]/a/div[1]/img"
然后我们在代码中以向上遍历节点拿到 xpath ,
// 获取 dom 的 xpath
function getXPath(element) {
let xpath = "";
for (
let me = element, k = 0;
me && me.nodeType == 1;
element = element.parentNode, me = element, k += 1
) {
let i = 0;
while ((me = me.previousElementSibling)) {
if (me.tagName == element.tagName) {
i += 1;
}
}
const elementTag = stringToLowerCase(element);
let id = i + 1;
id > 1 || k === 0 ? (id = "[" + id + "]") : (id = "");
xpath = "/" + elementTag + id + xpath;
}
return xpath;
}
那么拿到 xpath 之后,需要再映射回去的话,采用 document.evaluate() 方法。这个方法 ie 不支持的,但是有谁规定我们要使用 ie 在平台上进行圈选呢,毕竟 ie 的兼容性是我们所厌恶的。
另外,这是拿到当前元素 xpath,如果是圈选同级元素或是圈选所有子元素,那就再递归遍历一下就好了。后期再拿这些 xpath 去清洗、过滤、分析就能拿到我们想要的数据了。
3. 无痕埋点
无痕埋点也叫 “全埋点”,有了以上两种方式埋点,无痕埋点自然也就简单了,点击到任何 dom 时都进行上报,然后再获取 dom 的 xpath 作为唯一标识,就可以轻松实现全埋点上报了,剩下的就交给数仓获取、清洗数据吧。。。
document.body.addEventListener('click', function(e) {
...
// 第二步:获取目标元素(这里可以过滤掉点击到 body 的脏数据)
const el = e.target;
// 第三步:获取属性值和 xpath
const dataTpm = getNodeAttr(el, 'data-tpm');
const dataTpmArgs = getNodeAttr(el, 'data-tpm-args');
const xpath = getXPath(el);
// 第四步:将获取到的数据上报到 nginx,产生一条日志
const data = {
type: 'click',
ec: dataTpm,
ea: dataTpmArgs,
xpath,
...
};
sendToNginx(data);
...
}, false);
埋点上报到 nginx
这里我们在本地搭一个 nginx 来模拟一下,先做一些准备工作:
docker 拉一个 nginx 镜像,启动一个 nginx 容器;
在 nginx 容器中挂载一个静态资源文件,这里以 tpm.gif 为例,顺便修改一下该文件为 可读,否则会出现 403 错误;
做完这些,然后我们在本机浏览器访问 nginx 容器内的 tpm.gif 文件,带上一些 querystring 传递埋点信息,如下所示,
// 浏览器访问,或者 curl 一下
http://localhost:8080/tpm.gif?ss=1440x900&ws=709x775&sp=0x0&ac=Mozilla&an=Netscape&pf=MacIntel&lg=zh-CN&tz=-8&dpr=2&appid=portal-zh&csp=&gid=TY-58aaf9dfb80134ff&uid=guest&sver=3.3.12&aver=1.0.0&now=1606221479537&flt=1606221472429,1&src=&url=https://www.tuya.com/cn/&ref=&lang=&uuid=TY-58aaf9dfb80134ff-1606221479537&previous_uuid=TY-58aaf9dfb80134ff-1606221478012&previous_event=&seq_id=seq_id_eaf66e2bc936279a&sub_app_id=&type=pageClick&ea=&ec=&eh=&ep=485x37&xp=/html/body/div/div/div/div/div/div/div/div[1]&ct={%22tagName%22:%22div%22}&image=&text=
再去 nginx 上看下日志,nginx 日志默认打印在 access.log 文件中,文件中显示有一条访问 tpm.gif 的日志,如下图所示,
以上就是完整的一条埋点数据上报到 nginx ,然后云端需要做的是进行数据采集、分析和其它一系列操作,下面会具体介绍到如何采集数据。
埋点数据是如何收集的?
数据采集分为 实时数据 和 离线数据。
如下图所示,展示的是 实时数据 采集的数据流向的两种方式,主要经历的过程是:
第一种方式:目前应用场景主要是客户端实时校验。nginx 日志通过 filebeat 收集统一上报到 日志kafka,日志kafka 会接收到来自很多其它应用的日志数据,所以通过 flink 过滤出哪些数据是需要的数据(埋点数据)再上报到 数据kafka ,然后 Java应用 去消费这些数据,通过 websocket 把这些实时数据给 web 端。
第二种方式:主要是提供各种应用容器日志的数据查询,也就是 ELK 模型。nginx 日志通过 logstash 收集,logstash 和 filebeat 同样都可以做数据收集,它们的区别主要是,filebeat 是一个轻量型日志采集器,主要的能力是数据 收集;而 logstash 更多的能力是体现在数据的过滤和转换上。logstash 收集到数据后,将数据统一往 ES 中存,然后在 kibana 中建一个索引就可以看数据啦。
多区部署
通过前面的操作,就完成了一条完整的埋点数据从收集到查看。
但实际场景中我们可能会有多区部署的情况,并且在数据量很大的情况下需要多个 nginx 来做一层应用层面的负载均衡,然后又要在中国区看所有的所有区站点的数据。如下图所示,我们按之前的操作在各个区都部署一套,将埋点 js 文件每个区都发一遍,然后将收集到的数据统一汇聚到中国区计算。一般情况下看某一天所有区的数据,会有时差存在,一般离线数据以 T+1 的形式展现,这里再需要额外处理一下。
我们开开心心把上面流程都搞通了,一切大功告成,来杯 ☕️。
压测
流程都通了,我们还得看下并发量很高的情况下会不会丢数据。
我们在本机安装 key 压测工具,来模拟一下压测本机运行的 nginx 容器。安装好 hey 工具以后,在终端输入以下命令,一共发起 30000 个请求,并发数量为 3000 个,
# -c 要同时运行的 worker 数量
# -q 速率限制,每个 worker 的 QPS
# -n 请求数量
hey -c 50 -q 3000 -n 30000 -m GET http://localhost:8080/tpm.gif\?ss\=1440x900\&ws\=709x775\&sp\=0x0\&ac\=Mozilla\&an\=Netscape\&pf\=MacIntel\&lg\=zh-CN\&tz\=-8\&dpr\=2\&appid\=portal-zh\&csp\=\&gid\=TY-58aaf9dfb80134ff\&uid\=guest\&sver\=3.3.12\&aver\=1.0.0\&now\=1606221479537\&flt\=1606221472429,1\&src\=\&url\=https://www.tuya.com/cn/\&ref\=\&lang\=\&uuid\=TY-58aaf9dfb80134ff-1606221479537\&previous_uuid\=TY-58aaf9dfb80134ff-1606221478012\&previous_event\=\&seq_id\=seq_id_eaf66e2bc936279a\&sub_app_id\=\&type\=pageClick\&ea\=\&ec\=\&eh\=\&ep\=485x37\&xp\=/html/body/div/div/div/div/div/div/div/div\[1\]\&ct\=\{%22tagName%22:%22div%22\}\&image\=\&text\=
结果如下图所示,
30000 个请求状态都显示 200,实际的 QPS 为 1007.0157。
再进到 nginx 容器看下日志,追加的数量也为 30000 条,刚才不是有 2 条么,
好了,你可以喝 ☕️ 去了 。
参考链接
现有成熟的产品:
google 分析:https://analytics.google.com/analytics/web/provision/#/provision
百度统计:https://tongji.baidu.com/web/welcome/login
growingio:https://www.growingio.com/
神策数据:https://www.sensorsdata.cn/auto
关于本文 作者:@聂风 原文:https://zhuanlan.zhihu.com/p/313016178
为你推荐
【第2026期】「可视化搭建系统」——从设计到架构,探索前端领域技术和业务价值
欢迎自荐投稿,前端早读课等你来